[DO NOT MERGE] Infer through Class#new for unparameterized and generic Class receivers - #59
Draft
apiology wants to merge 2 commits into
Draft
[DO NOT MERGE] Infer through Class#new for unparameterized and generic Class receivers#59apiology wants to merge 2 commits into
apiology wants to merge 2 commits into
Conversation
RBS declares Class#new as (*untyped) -> untyped because RBS cannot parameterize Class. ApiMap#get_methods already replaces that pin with a self-returning synthesis for concrete namespaces, but deliberately skips rooted_tag Class / Class<Class>, leaving untyped - so Class.new.new and k.new (k: bare Class) were uninferrable. Proxy the pin instead: scope :class (literal Class.new) -> Class<Object> (the default-superclass anonymous class), scope :instance (.new on an unparameterized Class-typed receiver) -> Object (some instance of an unknown class). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT
For a receiver typed Class<generic<T>>, singleton-method lookup runs against the nonexistent 'generic' namespace, so no Class#new pin ever appears and .new goes undefined - even though the instance type is exactly the generic the caller binds. Synthesize a permissive new pin (restarg/kwrestarg, since the real initializer is unknown) returning the generic tag itself. Caller-side binding already worked; this fixes the method-body side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Parked: opened for future reference; the underlying defect is definition-site-only (callers are unaffected), so this waits until the problem is worth solving.
Problem
At
typecheck --level strong, Solargraph cannot verify the declared return type of a method that calls.newthrough aClass-typed receiver: it reports "return type could not be inferred" at the definition and forces a suppression there. The failure is confined to the definition site. Callers are unaffected — the declared return resolves at call sites, so there is no viralundefinedfrom this defect.Both shapes below are ordinary Ruby. The first is the instantiate-the-class-under-test helper that every typed test suite grows:
The second is the throwaway-class idiom behind test doubles and one-off adapters — build an anonymous class implementing a duck interface, instantiate it once:
(The
define_methodresolution inside the block is castwide#1310's territory; this PR fixes the return inference.)The value of this change is declaration verifiability — the definition typechecks against its own declared return, and the definition-site suppressions can be removed. It does not add caller-side coverage, which was never broken.
Solution
ApiMap#get_methodsalready repairs the untypedClass#newpin per-namespace (synthesizingnewfrominitialize), but misses two receiver shapes:Class— the repair deliberately skipsrooted_tagClass/Class<Class>. Now proxied:Class.new→Class<Object>,.newon a bare-Class-typed receiver →Object.Class<generic<T>>— class-method lookup on the nonexistentgenericnamespace finds noClass#newat all. Now synthesized: a permissive (restarg/kwrestarg)newreturning the generic tag, which caller-side binding already resolves.The synthesized
Class<generic<T>>#newaccepts any arguments: the initializer is statically unknowable from that type, so argument checking is out of scope here. That trade is strictly non-regressive — today the call is unresolved at the definition, which means a false error and zero argument checking; the fix adds typed returns without adding new unsoundness. (Sorbet'sT::Classdocs similarly note the type "only assumes those [methods] that are defined on::Class… basically, just.newand.name," without documenting constructor-argument checking for such receivers.)The new types are sound upper bounds:
Class<Object>means "a class whose instances are (at least) Object," so.new → Objectclaims the bound, not the exact class — at runtimeClass.new.newis an instance of a fresh anonymous class, never an Object-classed value.Objectis chosen over the strictly-lower boundBasicObjectbecause aBasicObject-typed result rejects nearly every subsequent call, and it matches Ruby's default superclass and existing core fills; the trade-off is one unsoundness edge for explicitClass.new(BasicObject)receivers, noted under alternatives.Background / prior art
Root cause: RBS cannot parameterize
Class, so it declaresClass#new: (*untyped, **untyped) -> untyped. At runtime,Class.newbuilds a fresh anonymous class (unnamed until assigned to a constant, superclassObjectby default), and its instances inspect as#<#<Class:0x...>:0x...>— the inner#<Class:0x...>is the anonymous class's address-based display name, the outer wrapper the instance of it.A singleton class "holds methods for only that instance," and the classic factory case —
Foo.new— dispatches inFoo's singleton class, which is where the attached-class mechanisms live: Sorbet'sT.attached_class("an instance of the current class") and RBS'sinstancetype are both resolved against that statically-known singleton context. Our two shapes operate outside any such context: the receiver is a value typedClass, so.newis instance-method dispatch on classClassand no singleton class is statically in play. Sorbet bridges that gap with the applied typeT::Class[X]— "any class object which, when instantiated, creates instances that at least have typeX" — an explicit upper bound built on the same mechanism asT.attached_class; RBS has no parameterization ofClassat all, henceuntyped. Solargraph'sClass<X>is itsT::Class[X]analog, and this PR supplies the most precise types expressible in Solargraph's current syntax under that reading.Related but distinct: castwide#1303 (constants assigned from
Class.newblocks); castwide#1310 addresses block-selfinsideClass.new do ... end.Alternative solutions
A first-class attached-class type in Solargraph's annotation syntax — an
attachedtoken (named to avoid colliding with RBS's classish-contextinstancesemantics), legal where the context supplies aClass<X>:Class#newcould then be declared once and the per-namespacenewsynthesis inget_methods— including both shapes fixed here — would collapse into a single declaration plus binder-aware substitution (the same shape as the existingself→self_to_typehandling). Not taken in this PR because it is new public annotation syntax (YARD compatibility, documentation, cross-tool expectations) touchingComplexTypeparsing, dispatch substitution, and generics erasure; the bounded proxy needs none of that, and its two proxy sites are exactly where anattachedtoken would later slot in.Superclass-argument binding for
Class.new(Superclass)— conceptually@generic T/@param superclass [Class<generic<T>>]/@return [Class<generic<T>>]on the synthesized pin, inferringClass<StandardError>fromClass.new(StandardError)and closing theClass.new(BasicObject)unsoundness edge. Three reasons it stays out of this PR. First, it semantically conflicts with the defect-2 fix in this same PR: superclass binding needs an unbound generic to degrade to its bound (no-argClass.newmust fall back toClass<Object>), while theClass<generic<T>>#newfix needs unbound generics kept symbolic for later caller-side binding — reconciling the two requires a design decision about when degradation happens, which deserves its own review. Second, blast radius: probed while preparing this PR, the pin synthesizes correctly (generics, typed optarg, generic return) but the chain layer's call-site binding does not engage for API-synthesized pins — the unresolvedClass<generic<T>>flows through and the no-arg case regresses to symbolic — so the fix lands in chain-layer resolution code that affects every method call, versus this PR's repair of one method's pin. Third, marginal value:Class.new(Superclass)results that need precise typing are typically assigned to constants, which is Methods on a Class.new-defined class are unresolvable and cannot be stubbed castwide/solargraph#1303's territory regardless of what the expression infers. The staticClass<Object>proxy is forward-compatible with adding the binding later.Test plan
Two clip specs updated from asserting
undefined, six added (both shapes, caller-side generic binding, args-through-generic-new). Full suite: 1630 examples, 0 failures, 60 pending.Opened as a draft. This PR was written by Claude (Anthropic's Claude Code) on behalf of @apiology.